iT邦幫忙

2023 iThome 鐵人賽

DAY 6
1
Mobile Development

30 天輕鬆學會 Flutter 測試系列 第 6

Day 6 不改變狀態也不回傳,那我怎麼測試?

  • 分享至 

  • xImage
  •  

這幾天的文章中,我們談論如何處理那些頑劣的依賴,透過 Stub 的方式,注入我們設計過的資料到測試之中,最後驗證回傳值或者物件狀態來決定測試成功與失敗。再討論 Stub 的時候,我們介紹了測試替身,也講到測試替身有許多種,今天就介紹另一種測試替身 Mock 與它的使用場景吧。

Mock 是什麼

那 Mock 是什麼呢?在前幾天我們有稍微介紹 Mock:Mock 是一個物件,用來驗證 SUT 是不是有正確跟這個 Mock 物件正確的互動,如果有正確的呼叫 Mock 物件身上的方法,那測試就會綠燈,反之則會紅燈。那為什麼我們會需要 Mock,想像一下,假設我們想測試的方法沒有回傳值,也不會改變自身的狀態時,我們就無法透過驗證狀態或回傳值等方式來測試,所以我們會需要 Mock。
1.png
出處:http://xunitpatterns.com/Mock%20Object.html

在上圖中,跟 Stub 一樣,我們會在 Arrange 階段建立 SUT 與注入 Mock 物件,也會在 Act 階段呼叫 SUT 身上的方法,但是最後是驗證 Mock 物件,而不是驗證 SUT,讓我們透過實際例子來感受一下 Mock 吧。

先舉個例子

假設我們有一個 PurchaseProductService 的類別,當使用者購買商品時,程式會呼叫 PurchaseProductService,檢查錢包是否有足夠錢,然後透過 ProductRepository 呼叫後端 API 購買。往下看之前,有興趣的觀眾朋友可能可以想想看,我們要怎麼測試這個類別。

class PurchaseProductService {
  final ProductRepository productRepository;

  PurchaseProductService(this.productRepository);

  void execute(Product product, Wallet wallet) {
    if (product.price > wallet.money) {
      throw MoneyNotEnoughException();
    }
    
    productRepository.purchase(product);
  }
}

這麼方法沒有回傳值,類別本身也沒有狀態可以拿來驗證,我們就沒辦法透過狀態驗證來決定是否成功,那我們要如何解決呢?讓我們手寫一個 Mock 物件來測試這個類別吧,新增一個假的 MockProductRepository 並設定預期的結果給它,然後這個 MockProductRepository 傳入 PurchaseProductService 之中呼叫完 execute 後,呼叫 MockProductRepository.verify 來確認結果是否符合預期,也就是 callCount 要等於 1 且 product 要是 Product(100)。

main() {
  test("purchase product success", () {
    var mockProductRepository = MockProductRepository();

    mockProductRepository.setExpectedCallCount(1);
    mockProductRepository.setExpectedProduct(const Product(100));

    var purchaseProductService = PurchaseProductService(mockProductRepository);

    purchaseProductService.execute(const Product(100), Wallet(200));

    mockProductRepository.verify();
  });
}

class MockProductRepository implements ProductRepository {
  int expectedCallCount = 0;
  int actualCallCount = 0;
  Product? expectedProduct;
  Product? actualProduct;

  void setExpectedProduct(Product product) {
    expectedProduct = product;
  }

  void setExpectedCallCount(int count) {
    expectedCallCount = count;
  }

  @override
  Future<void> purchase(Product product) async {
    actualProduct = product;
    actualCallCount ++;
  }

  void verify() {
    expect(actualProduct, expectedProduct);
    expect(actualCallCount, expectedCallCount);
  }
}

[範例連結]
是不是覺得寫 Mock 物件很累,其實如果真的要使用 Mock,我們有更輕鬆簡單的方式。包含前幾天介紹的 Stub 加上今天介紹的 Mock,當我們需要時,如果都得要花時間自己手刻,未免有點浪費時間,借助測試套件的幫助,讓我們能更快速的產生這些測試替身。

讓製作測試替身更容易

Flutter 測試相關的套件有許多,而其中常使用的肯定是 mockito 了,與 Java 著名的 Mockito 套件一樣,可以協助我們在測試中製作各式各樣的測試替身。以今天的例子來說,我們可以用 mockito 改寫一下。

@GenerateNiceMocks([MockSpec<ProductRepository>()])
main() {
  test("purchase product success", () {
    var mockProductRepository = MockProductRepository();

    var purchaseProductService = PurchaseProductService(mockProductRepository);

    purchaseProductService.execute(const Product(100), Wallet(200));

    verify(mockProductRepository.purchase(const Product(100))).called(1);
  });
}

首先我們得先在 main 上面加上 @GenerateNiceMocks 的 annotation,主要是讓 build_runner 可以自動幫我們產生 Mock 物件,接著我們就能直接使用 MockProductRepository 了,是不是很神奇。如果細心的觀眾朋友可能會注意到,當我們執行完 build_runner,在測試檔案旁邊會多一個 mock 檔案,這裡頭其實就是 mockito 幫我們產生好的 MockProductRepository。

class MockProductRepository extends _i1.Mock implements _i2.ProductRepository {
  @override
  _i3.Future<void> purchase(_i2.Product? product) => (super.noSuchMethod(
        Invocation.method(
          #purchase,
          [product],
        ),
        returnValue: _i3.Future<void>.value(),
        returnValueForMissingStub: _i3.Future<void>.value(),
      ) as _i3.Future<void>);
}

使用 mockito 來輔助製作 Mock 物件,能省去寫測試替身的時間,讓時間花在更有價值的事情上。雖然我們的例子中都用手寫測試替身來測試,但這只是希望方便大家了解測試替身的內涵,但實務中是不太會這樣做的,大多數語言都有方便製作測試替身的套件,使用這些套件節省時間,讓我們花更多時間專注實作與設計有效的測試案例是比較有價值的。

有興趣的朋友可以複製例子上的程式碼,然後使用 build_runner 產生 Mock 檔案後在執行測試。[範例連結]

Stub 也能用 mockito

mockito 除了可以用來產生 Mock 之外,還能於 Stub 假資料,讓我們改寫一下前幾天的 UserRepository 測試。

@GenerateNiceMocks([MockSpec<Client>()])
main() {
  test("get user ok from api", () async {
    var mockClient = MockClient();

    when(mockClient.get(
    Uri.parse("https://jsonplaceholder.typicode.com/users/1"))).thenAnswer(
      (_) async => Response("{\"id\":1, \"name\": \"Tom\"}", 200),
    );

    var userRepository = UserRepository(mockClient);

    var user = await userRepository.get(1);

    expect(user, User(id: 1, name: "Tom"));
  });
}

還記得前幾天我們自己實作的 StubClient,我們修改一下測試,改用 mockito 產生一個 MockClient,接著我們能用 mockito 中的 when 方法來作假 MockClient 中的 API 回傳值。以上面的例子來說,我們就指定了 mockClient.get() 在測試中會回傳指定 Response 物件。

狀態驗證 vs 行為驗證

至此我們已經認識了兩個最常用的測試替身 Stub 與 Mock 之外,而這兩個測試替身其實也分屬於兩種不同的驗證方式:狀態驗證行為驗證。顧名思義,狀態驗證的測試都是驗證物件身上的狀態或回傳值,來確認結果是否符合預期,而行為驗證則是確認 SUT 是否有呼叫依賴身上的方法,來決定結果是否符合預期。

這兩種測試方法倒也沒有誰好誰壞,不同的開發方式,也各自傾向的測試方式,有時候我們會只能驗證狀態,有時候我們只能驗證行為。但是當兩個方法都容易使用的時候,通常會更傾向於使用狀態驗證的方式,使用狀態驗證的測試,比較不容易因為架構調整而需要修改,驗證行為會造成測試認識物件的實作,當實作方式改變時,造成測試脆弱的問題。

除了 mockito 之外

測試套件除了 mockito 之外,由 Felix Angelov 製作的 mocktail 也是不錯的選擇。與 mockito 用法十分類似,一樣是使用 when 來設定假資料,一樣可以用 verify 來驗證互動,只是寫法上稍微有些差別。比較大的不同是,mockito 是使用 @GenerateMocks 或者 @GenerateNiceMocks 加上 build_runner 來產生測試替身,而 mocktail 則是需要自己寫一個 Mock 類別。

class MockClient extends Mock implements Client {}

雖然看似 mocktail 要自己寫比較麻煩,但實際上並不太花時間,有時候反而是反覆執行 build_runner 要更稍微花一點時間。

main() {
  test("get user ok from api", () async {
    var mockClient = MockClient();

    when(() => mockClient.get(Uri.parse("https://jsonplaceholder.typicode.com/users/1"))).thenAnswer(
      (_) async => Response("{\"id\":1, \"name\": \"Tom\"}", 200),
    );

    var userRepository = UserRepository(mockClient);

    var user = await userRepository.get(1);

    expect(user, User(id: 1, name: "Tom"));
  });
}

使用哪一個套件,還是可以根據觀眾自己的需求決定即可。

小結

Mock 主要用於驗證 SUT 與依賴的互動狀況,當測試無法透過狀態驗證來檢查結果時,我們就會建立 Mock 物件來協助檢查互動結果。在實務中,我們常常會使用測試套件來減輕寫測試的負擔,使用測試套件快速建立測試替身。在 Flutter 中,我們可以選擇 mockito 或 mocktail 來 Stub 或 Mock,使用測試套件不但可以減少寫的時候的負擔,也可以減少我們維護的的成本。


上一篇
Day 5 這段程式碼測不了
下一篇
Day 7 程式開發不只有正常路徑
系列文
30 天輕鬆學會 Flutter 測試30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言